Skip to content

fix(gem): warn when the hosted redirect leaves a stale unpatched install materialized - #219

Merged
Mikola Lysenko (mikolalysenko) merged 2 commits into
mainfrom
fix/gem-hosted-stale-install-warning
Aug 19, 2026
Merged

fix(gem): warn when the hosted redirect leaves a stale unpatched install materialized#219
Mikola Lysenko (mikolalysenko) merged 2 commits into
mainfrom
fix/gem-hosted-stale-install-warning

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Defect (D2, 2026-08-19 gem live-matrix — verified 3/3 in fresh containers)

scan --mode hosted for gems is a pure Gemfile/Gemfile.lock text rewrite with no stale-install handling. The natural user chain — bundle install first (gem materialized under BUNDLE_PATH), hosted redirect second — leaves the next bundle install inert: exit 0, Using activestorage 6.0.3, no refetch. The installed files and the cached .gem stay UPSTREAM/UNPATCHED while Gemfile + lock claim the patched registry. Silent, zero warnings, CVE stays live.

Empirical scoping (this PR's campaign, fresh containers, real prod patch pkg:gem/activestorage@6.0.3, uuid 15e960b5-f432-4b6c-b8aa-534a2b419323)

bundler warm bundle install after redirect --force/--redownload remove gems dir + cache .gem + specifications entry, then bundle install
1.17.3 INERT (exit 0, no marker, cache sha stays upstream 8ed4f462…) exists (--redownload is an alias) but does NOT heal — exit 0, reinstalls from the stale cache HEALS — cache sha becomes patched 52d9c99a…, marker present
2.7.2 INERT (exit 0, no marker) — the campaign's earlier "warm fetch" on b2/b4 was a warm-path artifact; the clean warm chain is inert both flags exist; same stale-cache reinstall behavior class HEALS
4.0.18 INERT (exit 0, no marker) — converged lock + CHECKSUMS do NOT protect: checksums verify at download time only, and nothing is downloaded does NOT heal — exit 37 "mismatched checksums" (loud, but upstream bytes stay installed; it reinstalls from cache instead of refetching) HEALS

Verdict: the defect is universal — the warning is emitted on every bundler major, and the remedy prescribes removal + bundle install, never --force/--redownload.

Fix (CLI-side, read-only — no rewriter changes)

After the gem hosted rewrite, run_redirect probes the project's installed-gem discovery paths — the existing RubyCrawler::get_gem_paths + find_by_purls APIs the apply flow uses (vendor/bundle/<engine>/<ver>/gems deployment layouts, or the gem env homes for non-deployment installs) — for each confirmed gem redirect, and hash-compares the materialization against the patch record's afterHash file map (verify_file_patch, the same primitive vex verification uses):

  • every file at afterHash → already patched, never warns — this is the one check that cannot false-positive: an agent-mode apply patches the installed tree in place while the cached .gem stays upstream, so the (also-considered) cache-sha comparison would cry wolf on every agent→hosted migration and was rejected;
  • anything else → a loud redirect_gem_stale_install warning in both the JSON envelope (redirect.warnings[]) and text output (code-tagged stderr line), naming the installed dir, the cache .gem, and the specifications entry, and prescribing the verified remedy;
  • read-only by contract: nothing is ever deleted (no destructive deletion by default); no bundler-version floor;
  • skipped on --dry-run (nothing rewritten, no records fetched) and when the patch record could not be fetched (record_fetch_failed already fired — no afterHash map, no sound judgment); re-fires on idempotent re-scans until the stale materialization is gone.

Live verification of the FIX (fixed binary in the containers, real prod patch)

  • bundler 4.0.18: end-to-end green — the warning fires naming the exact on-disk paths; following its prescription installs the patched gem (cache sha 52d9c99a5f87…, Socket Community Patch marker present).
  • bundler 1.17.3: no warning yet, no false positive — bundler 1's FLAT BUNDLE_PATH layout (vendor/bundle/gems/..., no ruby/<ver> scoping) is not discovered by today's crawler. Synergy with the in-flight crawler PR: detection goes through RubyCrawler::get_gem_paths, so the parallel flat-BUNDLE_PATH crawler extension makes this probe cover bundler 1 automatically — deliberately not duplicated here.

Tests (test-first, RED→GREEN)

  • New hermetic, non-ignored e2e tests/e2e_redirect_gem_stale_install.rs (real binary + wiremock, hand-laid deployment layout, no ruby/gem/bundler needed): stale materialization → exactly one warning naming all three paths + the remedy, tree untouched, stderr mirror on the human path (RED before the fix — verified failing with only redirect_gem_no_checksums_section/redirect_gem_frozen_install present); already-patched materialization → quiet; fresh checkout → quiet.
  • New unit tests in scan/hosted.rs: warning text (paths + verified remedy + --force/--redownload called out as non-remedies), afterHash judgment (patched/upstream/tampered/missing), probe end-to-end over a temp deployment layout (stale/patched/missing-record/zero-file-record/non-gem), uuid-fallback record lookup.
test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 395 filtered out   (--lib commands::scan::hosted)
test result: ok. 3 passed; 0 failed; 0 ignored                                   (e2e_redirect_gem_stale_install)
test result: ok. 1 passed; 0 failed; 0 ignored                                   (redirect_golden)
test result: ok. 410 passed; 0 failed; 1 ignored                                 (socket-patch-cli --lib)

Plus in_process_redirect, in_process_redirect_pnpm, e2e_vex_redirect all green; cargo fmt --check clean on touched files; cargo clippy --lib --test e2e_redirect_gem_stale_install -- -D warnings clean.

Golden suite / depscan twin

cargo test -p socket-patch-core --test redirect_goldengreen, untouched. The probe is CLI-side only; rewrite_gem and every fixture byte are unchanged, so no re-bless and no depscan gem.ts lockstep change is required for this PR. (The depscan hosted PR flow has no install-side context to probe; if the backend ever wants the equivalent guard, it is a separate design.)

Contract

  • CLI_CONTRACT.md: new "Gem stale-install guard" paragraph in the hosted-mode contract + a redirect_gem_stale_install row in the stable-tags vocabulary table (additive = MINOR per the table's own note).
  • docs/ecosystems.md: gem hosted column notes the guard.

🤖 Generated with Claude Code


Note

Low Risk
Warning-only CLI behavior with no lockfile rewriter changes; read-only detection and additive JSON/stderr contract (MINOR semver).

Overview
Hosted gem redirects only rewrite Gemfile/lock text; if a gem was already installed from upstream, later bundle install keeps the vulnerable bytes (verified across bundler 1.17 / 2.7 / 4.0). --force/--redownload do not fix it—they reinstall from the stale cached .gem.

After a successful hosted rewrite, run_redirect now runs a read-only probe for each confirmed pkg:gem/ redirect: RubyCrawler finds installed gems, compares files to the patch record’s afterHash map (same check as apply/vex). Already-patched trees stay quiet; stale upstream materializations emit redirect_gem_stale_install in redirect.warnings[] and on stderr (code-tagged), naming the gems dir, cache .gem, and specifications entry and prescribing removal plus bundle install. Skipped on --dry-run and when no record/afterHash exists; warnings repeat on re-scan until the stale install is gone. Exit code and status unchanged.

Contract updates: CLI_CONTRACT.md (gem stale-install guard + stable tag table), docs/ecosystems.md (hosted gem column). Unit tests in hosted.rs and hermetic e2e e2e_redirect_gem_stale_install.rs.

Reviewed by Cursor Bugbot for commit 47a23bc. Configure here.


Round 2 — adversarial review + Windows CI (commit 97771c8)

CI was RED on test (windows-latest) exactly as the review predicted: the round-1 unit tests substring-matched forward-slash/mixed-separator literals against Path::join output (backslash-joined on Windows). All fixed, plus the review's triaged findings:

MUST-FIX, all done:

  1. Windows paths — every compared path (unit + e2e, fixtures included) is now built with the same component-wise joins production uses; expected strings come from display() of identically-constructed PathBufs. The only remaining slash literal is a URL assert.
  2. --global/--global-prefix threading — the probe's CrawlerOptions mirrors scan's own discovery (was hardcoded false/None); pinned by gem_stale_probe_honors_global_prefix.
  3. Same-run --vex contradiction — the probe returns GemStaleOutcome { warnings, stale_purls }; stale purls are excluded from assume_applied, falling back to installed-tree verification (patched installs still attest with hash evidence; stale ones are omitted, and an all-stale --vex run fails per the embedded-VEX contract instead of attesting a live CVE). Pinned by gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run.
  4. Re-fire guarantee — record lookup is uuid-keyed, fresh records first then the redirect ledger's persisted ones; a transient /patches/view 5xx can no longer silently retire the warning. Pinned by gem_hosted_stale_warning_refires_when_record_fetch_fails (view 200 once, 500 after) + a unit twin.
  5. One shared oracle — core's vex::verify::verify_patch_record is now pub; the probe's duplicate gem_install_matches_record is deleted.
  6. False-positive hardening — staleness needs positive evidence: at least one record file whose bytes were actually read (current_hash present) and hash to neither expectation; missing/unreadable files (verify_file_patch folds IO errors into NotFound) never warn. Variant purls resolving to one installed dir stay quiet when ANY record judges it patched. Both pinned (gem_stale_positive_evidence_requires_readable_mismatched_bytes, gem_stale_probe_variant_records_stay_quiet_when_any_judges_patched).

SHOULD-FIX, all done: (7) committed vendor/cache/<leaf>.gem joins a project-local delete list (bundler installs from it first — a remedy leaving it behind silently reinstates stale bytes), and a sha256-mismatched committed archive warns standalone so a fresh checkout with a committed stale cache is no longer silent (sha from the registry override's gemChecksumSha256, hashed via core's file_sha256_hex; unreadable → no evidence → quiet); (8) shared gem-env homes get a prefer-project-local-bundle-path remedy instead of an unconditional delete prescription; (9) non-UTF8 dir leaves are skipped, the unreachable no-parent arm is an expect() invariant (per #198 convention); (10) record availability folds into the candidate filter with an early return before any crawler work or gem env spawn — the --dry-run gate at the call site stays deliberately explicit (review asked for "derived", but the round's own ledger fallback (4) breaks the derivation: a dry-run over an already-redirected project would otherwise warn about state the run didn't create — documented at the gate); (11) OR-assert split (code tag AND path asserted separately), two-gem-home multiplicity e2e added, e2e migrated onto tests/common/mod.rs run_with_env/parse_json_envelope (SOCKET_NO_UPDATE_CHECK now covered); (12) uuid-only lookup replaces purl-then-uuid (the fallback-only test became the uuid-keyed lookup test); (13) prose deduped — CLI_CONTRACT's "Gem stale-install guard" is the one canonical narrative, the table row / ecosystems.md / fn docs / e2e header are sentence + pointer, and the warning details keep only the load-bearing remedy text.

Skipped per triage (follow-ups): the pypi/composer sibling stale hazards (same structural issue — hosted rewrite is pure lockfile text with no stale-install handling; needs its own campaign), loop-inversion batching of locate_gem_dir, repair.rs integration beyond the one-liner (its hosted-skip message now notes the re-scan "also re-checks for stale pre-redirect installs").

Round-2 gates (all green):

cargo clippy --workspace --all-features -- -D warnings   (the exact CI invocation): clean
cargo test -p socket-patch-cli --lib:                    ok. 419 passed; 0 failed; 1 ignored
e2e_redirect_gem_stale_install:                          ok. 19 passed; 0 failed
e2e_vex_redirect / in_process_redirect / in_process_redirect_pnpm / repair_invariants:
                                                         ok. 5 / 41 / 8 / 20 passed; 0 failed
cargo test -p socket-patch-core --test redirect_golden:  ok. 1 passed (untouched, no re-bless)
cargo test -p socket-patch-core --lib vex::verify:       ok. 27 passed
rustfmt --check on all touched files:                    clean

…materialized

Live-verified defect (2026-08-19 gem matrix, D2): `scan --mode hosted` for
gems is a pure Gemfile/Gemfile.lock text rewrite with no stale-install
handling. On the natural warm path — `bundle install` first, redirect second
— the next `bundle install` exits 0, prints `Using <gem>`, and never
refetches: the installed files and cached .gem stay UPSTREAM while the
Gemfile + lock claim the patch registry. Re-verified UNIVERSAL in fresh
containers: bundler 1.17.3, 2.7.2, AND 4.0.18 all stay inert (bundler 4's
CHECKSUMS verify at download time only — nothing is downloaded), and
`bundle install --force`/`--redownload` do NOT heal it (they reinstall from
the stale cached .gem: bundler 1 silently, bundler 4 with an exit-37
refusal that still leaves upstream bytes installed). The only verified
remedy is removing the installed dir + cache .gem + specifications entry,
then `bundle install` (verified 3/3 majors).

After the rewrite, the hosted flow now probes the installed-gem discovery
paths (RubyCrawler — the same APIs apply uses) for each confirmed gem
redirect and hash-compares the materialization against the patch record's
afterHash file map. All-files-at-afterHash = already patched, never warned
(an agent→hosted migration stays quiet by construction — its cache .gem is
upstream, which is why a cache-sha check was rejected). Anything else emits
a loud, machine-readable `redirect_gem_stale_install` warning (JSON
redirect.warnings[] + code-tagged stderr) naming the three stale paths and
prescribing the verified remedy. Read-only: nothing is ever deleted.

Fix verified end-to-end in the bundler-4 container against the real prod
patch (pkg:gem/activestorage@6.0.3): warning fires naming the exact paths;
following the prescription installs the patched gem (cache sha 52d9c99a…).
Bundler 1's FLAT BUNDLE_PATH layout is not yet discovered by the crawler —
the in-flight flat-layout crawler extension makes this probe cover it
automatically (no duplication here by design).

redirect_golden: green untouched (no rewriter changes — the probe is
CLI-side); no re-bless, no depscan gem.ts lockstep needed for this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review + Windows-CI fix round on the redirect_gem_stale_install
guard:

- Windows CI RED fixed: every path assertion (unit + e2e) now builds its
  expected string with the same component-wise joins production uses —
  no embedded a/b/c literals whose display is substring-matched against
  backslash-joined output.
- The probe threads --global/--global-prefix into CrawlerOptions like
  every sibling discovery site (a stale gem in the run's own discovery
  home was invisible).
- Same-run --vex consistency: stale-flagged purls are returned
  structurally (GemStaleOutcome.stale_purls) and EXCLUDED from
  assume_applied — an envelope can no longer attest a CVE its own warning
  says is live; excluded purls fall back to installed-tree verification.
- Re-fire guarantee: record lookup is uuid-keyed and falls back to the
  redirect ledger's persisted records, so a transient /patches/view
  failure cannot silently retire the warning.
- One shared oracle: core's vex::verify::verify_patch_record is now pub
  and the probe's duplicate judgment (gem_install_matches_record) is
  deleted.
- False-positive hardening: staleness now needs POSITIVE evidence —
  readable bytes hashing to neither expectation; missing/unreadable files
  (verify_file_patch folds IO errors into NotFound) never warn. Variant
  purls resolving to one installed dir stay quiet when ANY record judges
  it patched.
- vendor/cache coverage: a committed bundle-cache archive joins a
  project-local delete list (bundler installs from it first), and a
  sha256-mismatched committed archive warns standalone — a fresh checkout
  with a committed stale cache no longer stays silent.
- Blast-radius wording split: shared gem-env homes get a
  prefer-project-local-bundle-path remedy instead of an unconditional
  delete prescription; non-UTF8 dir leaves are skipped, the unreachable
  parent arm is an expect() invariant.
- Early return before any crawler work / gem env spawn when no judgment
  is possible; prose deduped to one canonical CLI_CONTRACT narrative;
  repair's hosted-skip message acknowledges the stale-install re-check.
- New tests: two-gem-home multiplicity, global-prefix parity, ledger
  re-fire (view 5xx), same-run --vex exclusion, positive-evidence matrix,
  variant suppression, vendor/cache folded + standalone + patched-quiet;
  e2e migrated onto tests/common helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit 75b156d into main Aug 19, 2026
62 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the fix/gem-hosted-stale-install-warning branch August 19, 2026 19:17
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Aug 20, 2026
…lass-split fallback-home failure semantics

Review-round fixes for the two verified Bugbot findings on #222.

Finding 1 (Medium): the containment guard's
`gem_bundle_config_path_ignored` was a bare eprintln inside the crawler —
it never reached any --json `warnings[]` and printed under --silent,
violating the repo-wide warning conventions (#219/#220 + the #223
omnibus silent fixes). The crawler is now print-free: the refusal is
RECORDED on `BundleStoreDiscovery.skipped_config_path`, and a shared
`config_path_ignored_warning(value)` builder feeds the CLI channels —
scan pushes it onto the same run-level channel as the PnP layout
refusals (JSON `warnings[]` on both the zero-package and >=1-package
envelopes; one stderr line gated on !json && !silent), and apply carries
it in its Envelope `warnings[]` plus one gated stderr line. Scoped like
the crawl that hit it: local mode, gem in --ecosystems/manifest scope.

Finding 2 (High, with nuance): with an env/config bundle root,
get_gem_paths appends the gem-env fallback homes and the multi-copy
fan-out patched EVERY copy with per-copy loud-fail — so a gem present in
both the bundle store and a shared home (rvm @global, root-owned system
dir) failed the WHOLE run on a permission failure or variant mismatch
THERE, even though the copy bundler loads patched fine. Patching a
shared home's copy is not itself wrong (plain apply always patched
GEM_HOME when no store existed); the defect was failure semantics
crossing store classes. Fix: discovery's store list is exposed
(`RubyCrawler::discover_bundle_stores`, fs-probes only) and apply's gem
fan-out classes each copy — bundle-path store copies stay PRIMARY
(loud-fail, unchanged); gem-env fallback-home copies become BEST-EFFORT
once at least one store copy applied: a variant mismatch or write
failure there is a per-copy non-fatal Skipped event
(`gem_fallback_home_skipped`, detail names the path and reason; gated
stderr twin), never a run failure. Parity edge kept: with NO bundle-store
copy (the historic fallback-only layout, and every --global run) the
home copy IS primary and keeps loud-fail exactly as pre-#218 apply.

TDD evidence (red -> green on the rebased tip): scan/apply --json
missing the warnings[] entry and the --silent leak (3 tests,
in_process_gem_config_warning.rs); mismatched-home-copy exit 1 and
Failed-event-on-strict-refusal (in_process_gem_fallback_home.rs), with
fallback-only loud parity and both-copies-patched pinned green
throughout. CLI_CONTRACT.md documents the copy classes and the warning
channels.

Gates: touched files rustfmt-clean; cargo clippy --workspace
--all-features -D warnings clean; core --lib 2535, cli --lib 436;
crawlers::ruby 50, crawler_ruby_e2e 25; gem+npm multicopy 2+2;
apply_network 11, apply_invariants 4, cli_gem_variant_mismatch_policy 6,
cli_apply_silent 2, e2e_gem hermetic 8 (6 shown +cache selftests),
e2e_scan, cli_scan_silent, docker_e2e_gem — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants